<?php

/**
 * MNet Library 
 * Copyright (C) 2006-2008 Catalyst IT Ltd (http://www.catalyst.net.nz)
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * @package    mnet
 * @subpackage core
 * @author     Catalyst IT Ltd
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL
 * @copyright  (C) 2006-2008 Catalyst IT Ltd http://catalyst.net.nz
 * @copyright  (C) portions from Moodle, (C) Martin Dougiamas http://dougiamas.com
 *
 */


/**
 * Class representing an MNET-RPC request against a remote machine
 */
class mnet_client {

    private $method   = '';
    private $params   = array();
    private $timeout  = 60;
    private $rekey_lock = false; // set true to prevent loops when rekeying

    /**
     * Construct a new client
     */
    public function __construct() {
        
    }

    /**
     * Allow users to override the default timeout
     * @param   int $timeout    Request timeout in seconds
     * $return  bool            True if param is an integer or integer string
     */
    public function set_timeout($timeout) {
        if (!is_integer($timeout)) {
            if (is_numeric($timeout)) {
                $this->timeout = (integer($timeout));
                return true;
            }
            return false;
        }
        $this->timeout = $timeout;
        return true;
    }

    /**
     * Set the path to the method or function we want to execute on the remote
     * machine. Examples:
     * mod/scorm/functionname
     * auth/mnet/methodname
     * In the case of auth and enrolment plugins, an object will be created and
     * the method on that object will be called
     */
    public function set_method($xmlrpcpath) {
        if (is_string($xmlrpcpath)) {
            $this->method = $xmlrpcpath;
            $this->params = array();
            return true;
        }
        $this->method = '';
        $this->params = array();
        return false;
    }

    /**
     * Add a parameter to the array of parameters.
     *
     * @param  string  $argument    A transport ID, as defined in lib.php
     * @param  string  $type        The argument type, can be one of:
     *                              none
     *                              empty
     *                              base64
     *                              boolean
     *                              datetime
     *                              double
     *                              int
     *                              string
     *                              array
     *                              struct
     *                              In its weakly-typed wisdom, PHP will (currently)
     *                              ignore everything except datetime and base64
     * @return bool                 True on success
     */
    public function add_param($argument, $type = 'string') {

        $allowed_types = array('none',
                               'empty',
                               'base64',
                               'boolean',
                               'datetime',
                               'double',
                               'int',
                               'i4',
                               'string',
                               'array',
                               'struct');
        if (!in_array($type, $allowed_types)) {
            return false;
        }

        if ($type != 'datetime' && $type != 'base64') {
            $this->params[] = $argument;
            return true;
        }

        // Note weirdness - The type of $argument gets changed to an object with
        // value and type properties.
        // bool xmlrpc_set_type ( string &value, string type )
        xmlrpc_set_type($argument, $type);
        $this->params[] = $argument;
        return true;
    }

    /**
     * Send the request to the server - decode and return the response
     *
     * @param  object   $mnet_peer      A mnet_peer object with details of the
     *                                  remote host we're connecting to
     *                         
     * @return mixed                    A PHP variable, as returned by the
     *                                  remote function
     */
    public function send(mnet_peer $mnet_peer) {
        global $MNET_APPLICATION, $MNET;

        $MNET_APPLICATION->log(MNET_LOG_DEBUG, 'Sending encrypted call: '.$this->method.' '.print_r($this->params, true));

        // Encode the initial XML-RPC call into XML
        $xmlrpc_request = xmlrpc_encode_request($this->method, $this->params, array('encoding' => 'utf-8'));

        // Construct a new mnet_message to progress the XML through signing and encryption
        $request = new mnet_message($xmlrpc_request);

        // Sign the message using our local private key
        $request->sign($MNET->get_keypair_pem());

        // Encrypt the request using the server's public key
        //$MNET_APPLICATION->log(MNET_LOG_DEBUG, 'Remote pubkey used to encrypt : '.$mnet_peer->get_public_key());
        $request->encrypt($mnet_peer->get_public_key());

        $url = $mnet_peer->get_wwwroot().$mnet_peer->get_server_path();
        $timestamp_send    = time();
        $res = $MNET_APPLICATION->http_post($url, array('Content-Type' => 'text/xml; charset=UTF-8'), $request->get_xml(), $this->timeout);
        $timestamp_receive = time();

        if ($res['code'] != 200) {
            $MNET_APPLICATION->log(MNET_LOG_ERR, 'Failed XML-RPC request: '.$res['code'].' '.$res['error']);
            throw new mnet_exception('HTTP POST failed to '.$url);
        }

        // Construct a message for the response
        //$MNET_APPLICATION->log(MNET_LOG_DEBUG, 'Encrypted response: '.$res['response']);
        $response = new mnet_message($res['response']);

        // Expect the payload to be encrypted
        switch ($response->get_state()) {
            case mnet_message::STATE_ENCRYPTED:
                // this is what we expect, fall through
                break;

            case mnet_message::STATE_INVALID:
                $message = $response->get_xml_error();
                $message .= 'Document is: '.$response->get_xml();
                $MNET_APPLICATION->log(MNET_LOG_ERR, $message);
                throw new mnet_exception($message);

            case mnet_message::STATE_CLEARTEXT:
            case mnet_message::STATE_SIGNED:
                // Might be a fault - if so, throw it
                if ($fault = $this->parse_fault($response->get_xml())) {
                    throw $fault;
                }

                $MNET_APPLICATION->log(MNET_LOG_ERR, 'Payload not encrypted: '.$response->get_xml());
                throw new mnet_exception('Payload not encrypted');

            default:
                throw new mnet_exception('Invalid message state');
        }

        $MNET_APPLICATION->log(MNET_LOG_DEBUG, '1. Status of message is currently '.print_r($response->get_state(), true));

        // Ok, so the response is encrypted properly. Attempt decryption with
        // our current private key
        try {
            $response->decrypt($MNET->get_keypair_pem());
            $MNET_APPLICATION->log(MNET_LOG_DEBUG, '2. Status of message is currently '.print_r($response->get_state(), true));
        }
        catch (mnet_message_encryption_error $e) {
            $MNET_APPLICATION->log(MNET_LOG_NOTICE, 'Decryption of XMLRPC response failed. Trying old keys for peer '.$mnet_peer);
            
            // Try archived keys
            $keypair_history = $MNET_APPLICATION->get_keypair_history();
            foreach ($keypair_history as $keypair) {
                try {
                    $response->decrypt($keypair['keypair_PEM']);

                    // if we get here -> success
                    break;
                }
                catch (mnet_message_encryption_error $e2) {} // ignore
            }

            if ($response->get_state() == mnet_message::STATE_SIGNED) {
                $MNET_APPLICATION->log(MNET_LOG_NOTICE, 'Discovered old private key that can decrypt the XMLRPC response');
            }
        }

        if ($response->get_state() != mnet_message::STATE_SIGNED) {
            // we were unable to decrypt it with our archived keys
            $MNET_APPLICATION->log(MNET_LOG_ERR, 'None of our keys could open the payload from host '.$mnet_peer);
            throw new mnet_exception('Decryption of response failed');
        }

        // Margin of error is the time it took the request to complete.
        $margin_of_error  = $timestamp_receive - $timestamp_send;

        // Guess the time gap between sending the request and the remote machine
        // executing the time() function. Marginally better than nothing.
        $hysteresis       = ($margin_of_error) / 2;

        $remote_timestamp = $response->get_timestamp() - $hysteresis;
        $time_offset      = abs($remote_timestamp - $timestamp_send);
        if ($time_offset > 0) {
            $threshold = $MNET_APPLICATION->get_drift_threshold();
            if ($time_offset > $threshold) {
                $message = 'Time gap with '.$mnet_peer.' ('.$time_offset.' seconds) is greater than the permitted maximum of '.$threshold.' seconds';
                $MNET_APPLICATION->log(MNET_LOG_ERR, $message);
                throw new mnet_exception($message);
            }
        }

        // Validate the signature - it will have been signed with the version of the server's
        // private key matching the public key we initially made the request with
        if (!$response->check_signature($mnet_peer->get_public_key())) {
            // this response can't be trusted - don't try other keys because we know
            // it should be signed using the matching private key for the public key we
            // used, even if it's not their latest
            $MNET_APPLICATION->log(MNET_LOG_ERR, 'Response is not properly signed!');
            throw new mnet_exception('Response is not properly signed');
        }

        $MNET_APPLICATION->log(MNET_LOG_DEBUG, 'Response signature OK');

        // Check and update the protocol and server path in use by this peer
        $peer_protocol = $response->get_protocol();
        if ($peer_protocol && $peer_protocol != $mnet_peer->get_protocol()) {
            $mnet_peer->set_protocol($peer_protocol);
            $mnet_peer->commit();
        }
        $server_path = $response->get_server_path();
        if ($server_path && $server_path != $mnet_peer->get_server_path()) {
            $mnet_peer->set_server_path($server_path);
            $mnet_peer->commit();
        }

        // Unwrap the payload from the signature
        $response->strip_signature();

        // detect faults and catch special faults
        if ($fault = $this->parse_fault($response->get_xml())) {
            // The faultCode 7025 means we tried to connect with an old SSL key
            // The faultString is the new key - let's save it and try again
            // The re_key attribute stops us from getting into a loop
            if ($fault->getCode() == 7025 && !$this->rekey_lock) {
                $MNET_APPLICATION->log(MNET_LOG_NOTICE, 'Received outdated cert exception, server has provided new cert ('.md5($fault->getMessage()).')');

                // If the new certificate has invalid chars, error out
                if ($fault->getMessage() != $MNET_APPLICATION->clean_pem($fault->getMessage())) {
                    $MNET_APPLICATION->log(MNET_LOG_ERR, 'Failed to receive new public key cert via 7025 fault: '.$return_value['faultString']);
                    throw new mnet_xmlrpc_fault(7025, 'Invalid 7025 cert: '.$return_value['faultString']);
                }

                // Update the public key
                $new_pk                     = $fault->getMessage();
                $details                    = openssl_x509_parse($new_pk);
                if(!isset($details['validTo_time_t'])) {
                    // probably wasn't a public key after all...
                    $MNET_APPLICATION->log(MNET_LOG_ERR, 'Received cert wasn\'t a cert?');
                    throw new mnet_xmlrpc_fault($return_value['faultCode'], $return_value['faultString']);
                }
                $new_pk_expires             = $details['validTo_time_t'];

                // the $mnet_peer was passed in by reference, so we modify it now
                // and re-save
                $mnet_peer->set_public_key($new_pk, $new_pk_expires);

                // save the updated peer back
                $mnet_peer->commit();

                $MNET_APPLICATION->log(MNET_LOG_NOTICE, 'Successfully updated server\'s cert, retrying request');

                // Set the rekey lock so the call to send() below doesn't loop again if we receive another 7025
                $this->rekey_lock = true;
                
                // retry the request with the new public key
                return $this->send($mnet_peer);
            }

            // just a regular fault
            throw $fault;
        }

        // Parse the XML-RPC response value out of the remaining payload string
        return xmlrpc_decode($response->get_xml());
    }

    /**
     * Sends an unencrypted (vanilla) XML-RPC request to the remote peer.
     * 
     * Note that this is only allowed for some special methods such as system/*
     *
     * @param  object   $mnet_peer      A mnet_peer object with details of the
     *                                  remote host we're connecting to
     *                         
     * @return mixed                    A PHP variable, as returned by the
     *                                  remote function
     */
    public function send_unencrypted($mnet_peer) {
        global $MNET_APPLICATION, $MNET;

        // Encode the initial XML-RPC call into XML
        $request = xmlrpc_encode_request($this->method, $this->params, array('encoding' => 'utf-8'));

        $url = $mnet_peer->get_wwwroot().$mnet_peer->get_server_path();
        $res = $MNET_APPLICATION->http_post($url, array('Content-Type' => 'text/xml charset=UTF-8'), $request, $this->timeout);

        if ($res['code'] != 200) {
            $MNET_APPLICATION->log(MNET_LOG_ERR, 'Failed XML-RPC request: '.$res['code']);
            return false;
        }

        // Decode the response (which should also be unencrypted)
        $return_value = xmlrpc_decode($res['response']);

        // detect faults
        if (is_array($return_value) && array_key_exists('faultCode', $return_value)) {
            throw new mnet_xmlrpc_fault($return_value['faultCode'], $return_value['faultString']);
        }

        return $return_value;
    }
    
    /**
	 * Checks if the supplied XML amounts to an XML-RPC fault, if so, converts it to
	 * an mnet_xmlrpc_fault exception and returns it
	 * 
	 * @param string $xml the XML to check
	 * @return mnet_xmlrpc_fault an mnet_xmlrpc_fault instance, populated, or false if the XML wasn't a fault string
     */
    private function parse_fault($xml) {
        $decoded = xmlrpc_decode($xml, 'UTF-8');

        if (is_array($decoded) && xmlrpc_is_fault($decoded)) {
            // looks like it is a fault
            $fault = new mnet_xmlrpc_fault($decoded['faultCode'], $decoded['faultString']);
            return $fault;    
        }

        // not a fault
        return false;
    }
}
